feat(paper): an hourly-cadence paper profile — evidence at a collectable rate (#337) - #344
Merged
Merged
Conversation
…ble rate (#337) The paper deployment evaluates once per UTC day and daily-turtle rules fire 1.19–3.20 times per asset-year, so the gate's n=100 is 31–84 years away. The same rules on ONE_HOUR bars fire ~50/asset-year (median n≈268 over the cached 5-year window) — measured NET-NEGATIVE (0 of 90 / 0 of 82 cells at every reachable fee, restated 2026-08-13). This profile exists to produce ADMISSIBLE EVIDENCE (rail vetoes, outcomes, pending lifespans, intent divergence — unobservable in backtests), not profitability. - TurtleBreakout: granularity is now a persisted constructor param (RsiMeanReversion.timeframe's convention), default ONE_DAY so every pre-existing row keeps meaning daily; detect()/exit_signal() read the declared granularity's series via _trading_series. - agent: _GRANULARITY_PARAMS registers turtle_breakout's granularity, so hourly rows round-trip through the shared coercion boundary. - Tracked deployment profile: config.paper-hourly.yaml (paperforward's universe at interval_sec 3600), com.keel.paper-hourly.plist (24 hourly triggers at :20 + RunAtLoad), paper-hourly-run.sh (UTC-HOUR stamp — the paperforward day-stamp would collapse 23 of 24 cycles), keel-paperhourly wrapper pinning config + keel-paperhourly.db. - Operator runbook: third profile column + 'The hourly evidence profile' section (purpose, net-negative caveat, exact bootstrap). Closes #337
…series staleness wording, runbook table rewrapped
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The paper deployment evaluates once per UTC day and daily-turtle rules fire 1.19–3.20 times per asset-year — the promotion gate's n=100 is 31–84 years away. The same rules on
ONE_HOURbars fire ~50/asset-year (median n≈268 over the cached 5-year window), making the sample collectable in months. The honest caveat, stated up front because it changes nothing about the decision: the hourly configuration is measured NET-NEGATIVE — 0 of 90 / 0 of 82 cells at every reachable fee (docs/experiments/2026-08-13-restated-under-a-production-faithful-engine.md). This profile exists to produce ADMISSIBLE EVIDENCE — rail vetoes, outcomes, pending lifespans, intent divergence: the things a backtest cannot observe — not profitability. That caveat is pinned in the config header, the plist comment, and the runbook by tests, so it cannot silently disappear.Investigation
1. How does the agent map rules to candle granularity?
A cycle feeds each rule candles of the rule's DECLARED granularity, not the profile's configured granularity and not "whatever was fetched". The plumbing:
agent.run_oncepolls and reads every granularity the profile configures (keel/agent.py:855—granularities = list(config.market_data.granularities);:1064—candles_by_tf = {g: repo.get_candles(product_id, g) for g in granularities}). The profile's config is the menu, not the choice.engine._trading_granularity(keel/strategy/engine.py:225-237) returnsrule.granularity/rule.timeframeif declared, else the finest available.agent._entry_gate_granularity(keel/agent.py:221-249) reads the same attributes to gate entries (falling back to the coarsest configured series for rules that declare neither — the DCA hazard).backtest._rule_trading_tf(keel/strategy/backtest.py:208-218) does the same for backtests.self.granularity = Granularity.ONE_DAYfixed in__init__(keel/strategy/rules/turtle_breakout.py, pre-PR line 140), anddetect()/exit_signal()read theONE_DAYkey unconditionally via_completed_days. No configuration could point the rule at another series — which is why the 2026-08-11 hourly corpus had to hand hourly bars to a rule that "believed they were days" (docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md§2/§7:backtestkeys the fetchedONE_HOURseries under the rule's declaredONE_DAY, "the rule cannot tell").rules.params), constrained by the profile'smarket_data.granularities:PullbackContinuationdeclaresgranularity(constructor param, defaultONE_HOUR) andRsiMeanReversiondeclarestimeframe(dataclass field, persisted asself.timeframe.value, coerced back byagent._GRANULARITY_PARAMS). Pullback is the cautionary tale: it acceptsgranularitybut does NOT persist it indescribe()["params"], sorules addrefuses it rather than silently rebuild the rule at the default on a different candle series (keel/commands/rules.py:822-824, 967-984).Consequence: an hourly paper profile needs turtle's declaration itself to be a param. This PR adds it (see below).
2. Can an hourly cycle keep ONE_HOUR bars current within venue limits?
Yes, comfortably:
poll_once(keel/data/market_feed.py:184-221) fetches, per(product, granularity)pair, only candles strictly newer than the latest stored and no later than the most recently closed one. Steady state at hourly cadence: ~one small request per pair per cycle. This profile: 5 traded products × 3 configured series = ~15 read-only candle requests per hour (ONE_DAY accrues 1 bar/day, ONE_HOUR 1/hour, FIFTEEN_MINUTE 4/hour) — far under Coinbase's public-endpoint budget, and identical in shape to what the live profile's 24 hourly triggers already do today.MAX_CANDLES_PER_REQUEST = 300(keel/data/history.py:30) andmarket_feed._request_windows/_poll_catch_up(keel/data/market_feed.py:61-77, 150-181) tile any catch-up range into ≤300-candle windows, upserting per window.repair.pyapplies the same chunking to interior gaps and records "absent at source" windows so permanently empty ones stop retrying. So a machine that was off for days catches up in ONE poll — 300 hourly bars ≈ 12.5 days per window, and windows repeat.interval_sec × FEED_STALENESS_CYCLES(keel/execution/guards.py:125) = 3600×3 = 3h here, on the finest configured series (FIFTEEN_MINUTE). The entry gatefreshness.entry_bar_ready(keel/data/freshness.py:126-237) for anONE_HOUR-declared rule requires the newest stored hourly bar to be the expected one and every finer series to have crossed the boundary — the :20 trigger's 20-minute publication margin (same margincom.keel.liveuses for the same reason) keeps that from racing; when it does race, the cycle exitsDATA_NOT_READY_EXIT=4, the hour goes unstamped, and the next trigger retries.3. What defines a "profile"?
Four tracked artifacts per profile (tracked in-repo since 2026-08-03 per
docs/RELEASING.md:101-104; the scripts' older "gitignored" header lines are stale on that point and this PR's new files say "tracked"): config (config.paperforward.yaml/config.live-sandbox.yaml), database (keel.db/keel-live.db— the--dbCLI flag, defaultkeel.db), launchd plist (com.keel.paperforward.plist— local-anchored, 09:00–20:00 hourly triggers + RunAtLoad, LOCAL day-stamp;com.keel.live.plist— 24 hourly triggers at :20, UTC day-stamp, where the stamp is a correctness mechanism because nothing on the live path dedupes an entry, pinned bytests/test_schedule.py), and run script + wrapper (paperforward-run.sh+keel-paper;keel-live-run.sh+keel-live, the wrapper pinning--config X --db Ytogether so--db'skeel.dbdefault can never silently cross the ledgers). The operator-facing contract for the pair lives indocs/operator-runbook.md"Paper vs. live". This PR adds the third profile in exactly that shape:config.paper-hourly.yaml+keel-paperhourly.db+com.keel.paper-hourly.plist+paper-hourly-run.sh+keel-paperhourly.The code change (and the default-compatibility story)
TurtleBreakoutgainsgranularity: Granularity = Granularity.ONE_DAY:timeframe(the convention that round-trips): persisted inparamsasgranularity.value(a JSON-plain string), registered inagent._GRANULARITY_PARAMSsobuild_rule_from_paramscoerces"ONE_HOUR"back to the enum. Not Pullback's non-persisted convention, whichrules addrefuses.detect()/exit_signal()readself._trading_series(candles_by_tf): theONE_DAYdefault keeps_completed_days' forming-bar guard verbatim (the account-sim lookahead guard, pinned by existing tests); any other declared granularity reads that series verbatim — the same contract pullback/rsi already trade under (agent persists only closed candles; the sim decides at the current bar's close). An absent key declines as insufficient history rather than silently falling back to another granularity's bars.keel.dbandkeel-live.dbwith nogranularitykey; they rebuild at theONE_DAYdefault and keep meaning exactly what they meant. This is the asymmetry_params_delta(keel/commands/rules.py:734-740) already documents for any kind that grows a param, anddeploy/live-rules.jsonis deliberately NOT regenerated (it mirrors the live rows, which are unchanged). Pinned bytest_a_row_written_before_the_param_existed_defaults_to_daily._entry_gate_granularity), the engine's trading-TF and higher-TF bias gate (an hourly turtle gets the ONE_DAY bias check, like every hourly rule), andbacktest._rule_trading_tfall read the declared attribute.The deployment profile
config.paper-hourly.yaml— paperforward's exact universe (same allowlist/weights/caps/fees/paper seed, pinned by test) atinterval_sec: 3600, header carrying the net-negative caveat and the separate-database contract.com.keel.paper-hourly.plist— 24 hourly triggers at :20 + RunAtLoad, comment blocks matching the existing plists (including the XML no-double-hyphen rule).paper-hourly-run.sh— its own stamp semantics: stamps the UTC hour (date -u '+%Y-%m-%dT%H'). The paperforward day-stamp is daily-grained and would collapse 23 of 24 cycles into no-ops (the exact regression a copy-paste would ship — mutation-checked red, see below). The stamp is cadence bookkeeping here, not the live path's duplicate-entry barrier: paper already refuses a second entry while a product is open (strategy/paper.py), but duplicate cycles would still inflate the rail-veto/no-signal evidence counts.keel-paperhourly— wrapper pinning--config config.paper-hourly.yaml --db keel-paperhourly.db.docs/operator-runbook.md— third column in the paper-vs-live table + "The hourly evidence profile" section: purpose (evidence cadence), the net-negative caveat, the separate database with exact bootstrap (keel migrate --db, per-productrules add --params '{"granularity": "ONE_HOUR"}', deliberaterules promote --forcewith the reason,keel fetch), the one-param difference from every other turtle row, and the honest limits (an hour lost to power-off is lost; the runner cannot replay bars).Tests-first evidence
Red (before the param existed), failing on the assertion meant to assert — not an import error:
plus
test_an_hourly_rule_detects_on_the_one_hour_series(TypeError: unexpected kwarg) andtests/test_agent.py::test_coerced_param_keys_...(frozenset mismatch). Green after the change: same tests pass, full suite below.For the runner's regression tests (written after the runner, so discrimination was proven by mutation instead — both mutations confirmed red, then reverted and re-verified green):
date -u '+%Y-%m-%d') →test_the_next_utc_hour_runs_its_own_cycleFAILED (1 failed);--db keel-paperhourly.dbpin →test_the_cycle_runs_the_hourly_config_against_its_own_databaseFAILED (1 failed).Gates (all must pass)
uv run ruff checkclean —All checks passed!uv run mypyclean —Success: no issues found in 238 source filesuv run pytest -qgreen —2909 passed, 1 skipped in 36.85sScope check
config.paperforward.yaml/com.keel.paperforward.plist/paperforward-run.shdiff-free).Closes #337